/**
 * File SlidingPanel.java
 * ---------------------------------------------------------
 *
 * Copyright (C) 2012 David Bauske (david.bauske@googlemail.com)
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
 *
 * - The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
 * - The origin of the software must not be misrepresented.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 *
 * TL;DR: As long as you clearly give me credit for this Software, you are free to use as you like, even in commercial software, but don't blame me
 * 		if it breaks something.
 */
package net.cl.util.ui;

import java.awt.Component;
import java.awt.LayoutManager;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.util.HashMap;
import java.util.Map;

import javax.swing.JLayeredPane;

import net.cl.util.anim.Animation1D;
import net.cl.util.anim.AnimationAdapter;
import net.cl.util.anim.EaseInInterpolator;
import net.cl.util.anim.Interpolator;

/**
 * Panel that behaves in a similar manner to the iPad's task manager:
 * Slides the main content up, a smaller panel appears.
 *
 * @author David
 */
public class SlidingPanel extends JLayeredPane implements MouseListener
{
	// ---------------------------------------------------------------------------
	// Fields
	// ---------------------------------------------------------------------------

	private static final long				serialVersionUID			= 8360154460972232528L;
	private static final Integer			BACKGROUND_LAYER			= Integer.valueOf(2);
	private static final Integer			MAIN_LAYER					= Integer.valueOf(3);

	public static final Interpolator		DEFAULT_INTERPOLATOR		= null;
	public static final double				DEFAULT_DURATION			= -1;

	private Component						mMainComponent;
	private Map<Position, BorderComponent>	mBorderComponents			= new HashMap<Position, BorderComponent>();
	private Position						mActiveBorderPosition		= null;

	private boolean 						mCloseOnClick				= false;

	/**
	 * Whether the panel moves should be animated or not.
	 */
	private boolean							mAnimatePanel				= true;

	/**
	 * Time in seconds the animation should take.
	 */
	private double							mDefaultAnimationDuration	= 0.3;
	private Interpolator					mDefaultInterpolator		= EaseInInterpolator.INSTANCE;
	private Animation1D						mCurrentAnimation			= new Animation1D();

	// ---------------------------------------------------------------------------
	// Public methods
	// ---------------------------------------------------------------------------

	public SlidingPanel()
	{
		super.setLayout(new SlidingLayout());

		// Set the animator listener
		this.mCurrentAnimation.addListener(new AnimationAdapter()
		{
			@Override
			public void onProgress(Animation1D pAnimation, double pCurrentValue)
			{
				SlidingPanel.this.mMainComponent.setLocation(
						mMainComponent.getX(),
						(int) pCurrentValue);
				SlidingPanel.this.repaint();
			}
		});
	}

	/**
	 * Sets the main component of this Sliding panel. The main component is the one that is normally displayed,
	 * thus when none of the menus are visible. The main component will usually take up all of the space in the
	 * panel.
	 * <p>
	 * There can only be one main component at a time in a SlidingPanel. When you call this method again,
	 * the previous main component will be replaced.
	 * </p>
	 *
	 * @param pComponent The component to use as the main coponent.
	 */
	public void setMainComponent(Component pComponent)
	{
		if(mMainComponent != null)
		{
			mMainComponent.removeMouseListener(this);
			super.remove(mMainComponent);
		}
		mMainComponent = pComponent;

		if(this.mCloseOnClick)
		{
			mMainComponent.addMouseListener(this);
		}
		super.add(pComponent, MAIN_LAYER);
	}

	/**
	 * Sets whether or not to use the Close-On-Click Feature.
	 *
	 * If enabled, this feature automatically slides the main component back into it's
	 * original position if the user clicks anywhere on the main component. This is
	 * the behavior known from the iPad task bar.
	 *
	 * @param pCloseOnClick true to Close-On-Click, false to let user code handle
	 * 		closing.
	 */
	public void setCloseOnClick(boolean pCloseOnClick)
	{
		if(this.mCloseOnClick != pCloseOnClick)
		{
			if(pCloseOnClick && this.mMainComponent != null)
			{
				this.mMainComponent.addMouseListener(this);
			}
			this.mCloseOnClick = pCloseOnClick;
		}
	}

	/**
	 * @return true if the main component slides back if the user clicks anywhere
	 * 		outside the active border component.
	 */
	public boolean isCloseOnClickEnabled()
	{
		return this.mCloseOnClick;
	}

	/**
	 * @return The current main component of this SlidingPanel.
	 * @see #setMainComponent(Component)
	 */
	public Component getMainComponent()
	{
		return mMainComponent;
	}

	/**
	 * Sets a border component. A border component is a swing component that is normally
	 * invisible and under the main component, but when the {@link #showBorderComponent(Position)}
	 * method is called, the main component slides away and the border component will be
	 * visible.
	 * <p>
	 * The added component will initially be hidden.
	 * </p>
	 *
	 * @param pComponent The component to add as a border component
	 * @param pPos The position where the new border component should be placed.
	 */
	public void setBorderComponent(Component pComponent, Position pPos)
	{
		BorderComponent old = mBorderComponents.get(pPos);
		if(old != null)
		{
			super.remove(old.mComponent);
		}
		BorderComponent newComp = new BorderComponent(pComponent);
		mBorderComponents.put(pPos, newComp);
		super.add(pComponent, BACKGROUND_LAYER);
	}

	/**
	 * Similar behavior to {@link #setBorderComponent(Component, Position)}, but more
	 * customizable. Note that all animation settings will only take effect if panel
	 * animations are enabled (see {@link #enablePanelAnimation(boolean)}).
	 *
	 * @param pComponent The component to add as a border component
	 * @param pPos The position where the new border component should be placed.
	 * @param pShowInterpolator The interpolator used when the border component is shown.
	 * 			Pass {@link #DEFAULT_INTERPOLATOR} to use this panel's default interpolator.
	 * @param pShowTime Time in seconds it takes to show the component. Pass
	 * 			{@link #DEFAULT_DURATION} to use this panel's default duration.
	 * @param pHideInterpolator The interpolator used when the border component is shown.
	 * 			Pass {@link #DEFAULT_INTERPOLATOR} to use this panel's default interpolator.
	 * @param pHideTime Time in seconds it takes to hide the component. Pass
	 * 			{@link #DEFAULT_DURATION} to use this panel's default duration.
	 */
	public void setBorderComponent(Component pComponent, Position pPos,
			Interpolator pShowInterpolator, double pShowTime,
			Interpolator pHideInterpolator, double pHideTime)
	{
		BorderComponent old = mBorderComponents.get(pPos);
		if(old != null)
		{
			super.remove(old.mComponent);
		}
		BorderComponent newComp = new BorderComponent(pComponent,
				pShowInterpolator, pShowTime,
				pHideInterpolator, pHideTime);
		mBorderComponents.put(pPos, newComp);
		super.add(pComponent, BACKGROUND_LAYER);
	}

	/**
	 * @return The currently active border component's position or null if none is
	 * 			visible at the moment.
	 */
	public Position getActiveBorderComponent()
	{
		return mActiveBorderPosition;
	}

	/**
	 * Shows the border component at the given position.
	 * @param pPosition The position of the element to show. May not be null.
	 * @throws NullPointerException if pPosition is null
	 * @throws NoSuchComponentException if there is no component at the given position
	 */
	public void showBorderComponent(Position pPosition)
	{
		if(pPosition == null)
		{
			throw new NullPointerException("The Position may not be null.");
		}

		BorderComponent borderComponent = mBorderComponents.get(pPosition);
		if(borderComponent == null)
		{
			throw new NoSuchComponentException("No element at position " + pPosition);
		}

		mActiveBorderPosition = pPosition;

		if(this.mAnimatePanel)
		{
			// Start the animator job
			this.animate(this.mMainComponent.getY(),
					this.getInsets().top + pPosition.shiftMainComponent(borderComponent.mComponent.getPreferredSize().height),
					borderComponent.mShowSettings.mDuration,
					borderComponent.mShowSettings.mInterpolator);
		}
		else
		{
			// Instantly re-layout
			doLayout();
		}
	}

	/**
	 * Hides the currently visible menu component, thus restoring the original state of the SlidingPanel.
	 * <p>
	 * If no special component is currently visible, the method will have no effect.
	 * </p>
	 */
	public void hideBorderComponent()
	{
		if(mActiveBorderPosition != null)
		{
			AnimationSettings animSettings = mBorderComponents.get(mActiveBorderPosition).mHideSettings;
			mActiveBorderPosition = null;

			if(this.mAnimatePanel)
			{
				// Start the animator job
				this.animate(this.mMainComponent.getY(), this.getInsets().top,
						animSettings.mDuration,
						animSettings.mInterpolator);
			}
			else
			{
				// Instantly re-layout
				doLayout();
			}
		}
	}

	/**
	 * Toggles the visibility status of the border component at the given position.
	 * @param pPosition The position of the border component to show. May not be null.
	 * @throws NullPointerException if pPosition is null.
	 */
	public void toggleBorderComponent(Position pPosition)
	{
		if(mActiveBorderPosition == pPosition)
		{
			// The component is already visible, retract it.
			hideBorderComponent();
		}
		else
		{
			// Expand the component at the given position
			showBorderComponent(pPosition);
		}
	}

	/**
	 * Returns a border component.
	 * @param pPos The position of the border component. May not be null.
	 * @return The requested component.
	 * @throws NullPointerException if pPos is null.
	 * @throws NoSuchComponentException if there is no component at this position.
	 */
	public Component getBorderComponent(Position pPos)
	{
		if(pPos == null)
		{
			throw new NullPointerException("The Position may not be null.");
		}

		BorderComponent result = mBorderComponents.get(pPos);
		if(result == null)
		{
			throw new NoSuchComponentException("No element at position " + pPos);
		}
		return result.mComponent;
	}

	/**
	 * @return <i>true</i>, if any of this SlidingPanel's border components are currently
	 * 		visible, <i>false</i> otherwise.
	 */
	public boolean isBorderComponentVisible()
	{
		return mActiveBorderPosition != null;
	}

	/**
	 * Checks the existence of a border component at a specified position.
	 * @param pPos The position to check. May not be null.
	 * @return <i>true</i>, if a component exists at position <i>pPos</i>, false otherwise.
	 */
	public boolean hasBorderComponentAt(Position pPos)
	{
		if(pPos == null)
		{
			throw new NullPointerException("Position argument may not be null.");
		}
		return this.mBorderComponents.get(pPos) != null;
	}

	/**
	 * Cancels any possibly ongoing animations.
	 * Has no effect if the panel is currently not animating.
	 */
	public void cancelAnimation()
	{
		this.mCurrentAnimation.cancel();
	}

	/**
	 * Sets the default duration for a sliding animation.
	 * @param pDuration The duration of the animation in seconds.
	 * @throws IllegalArgumentException if pDuration is less than zero.
	 */
	public void setDefaultAnimationDuration(double pDuration)
	{
		if(pDuration < 0)
		{
			throw new IllegalArgumentException("Duration may not be negative, thus " +
					pDuration + " is invalid.");
		}
		this.mDefaultAnimationDuration = pDuration;
	}

	/**
	 * @return The duration in seconds of an animation.
	 */
	public double getDefaultAnimationDuration()
	{
		return this.mDefaultAnimationDuration;
	}

	/**
	 * Enables or disables animations for this SlidingPanel.
	 * @param pAnimate <i>true</i> to animate the panel, <i>false</i> otherwise.
	 */
	public void enablePanelAnimation(boolean pAnimate)
	{
		this.mAnimatePanel = pAnimate;
	}

	/**
	 * @return <i>true</i>, if this panel is animated; <i>false</i> otherwise.
	 */
	public boolean isPanelAnimation()
	{
		return this.mAnimatePanel;
	}

	/**
	 * Sets the default interpolator for this SlidingPanel. Will only affect components that are
	 * added to the panel after this call.
	 * @param pInterpolator The new interpolator to use. May not be nul.
	 * @throws NullPointerException if pInterpolator is null.
	 */
	public void setDefaultInterpolator(Interpolator pInterpolator)
	{
		if(pInterpolator == null)
		{
			throw new NullPointerException("pInterpolator may not be null.");
		}
		this.mDefaultInterpolator = pInterpolator;
	}

	/**
	 * @return The default interpolator for this SlidingPanel.
	 */
	public Interpolator getDefaultInterpolator()
	{
		return this.mDefaultInterpolator;
	}

	// ------------------------------------------------------------------------------
	// Private methods
	// ------------------------------------------------------------------------------

	private void animate(double pStart, double pEnd, double pDuration, Interpolator pInterpolator)
	{
		this.mCurrentAnimation.cancel();

		this.mCurrentAnimation.setFrameRate(Animation1D.DEFAULT_FRAME_RATE);
		this.mCurrentAnimation.setDuration(pDuration);
		this.mCurrentAnimation.setInterpolator(pInterpolator);
		this.mCurrentAnimation.setStartValue(pStart);
		this.mCurrentAnimation.setTargetValue(pEnd);

		this.mCurrentAnimation.start();
	}

	// ---------------------------------------------------------------------------
	// Unsupported operations
	// ---------------------------------------------------------------------------

	/**
	 * Not implemented. You cannot replace this component's layout manager.
	 * This methods simply ignores whatever you pass it.
	 */
	@Override
	public void setLayout(LayoutManager pManager)
	{

	}

	/**
	 * Not implemented. Please use {@link #setMainComponent(Component)} or
	 * {@link #setBorderComponent(Component, Position)} instead.
	 */
	@Override
	public Component add(Component cmp)
	{
		throw new UnsupportedOperationException("Manually adding a component to a SlidingPanel is not allowed.");
	}

	/**
	 * Not implemented. Please use {@link #setMainComponent(Component)} or
	 * {@link #setBorderComponent(Component, Position)} instead.
	 */
	@Override
	public Component add(Component cmp, int index)
	{
		throw new UnsupportedOperationException("Manually adding a component to a SlidingPanel is not allowed.");
	}

	/**
	 * Not implemented. Please use {@link #setMainComponent(Component)} or
	 * {@link #setBorderComponent(Component, Position)} instead.
	 */
	@Override
	public void add(Component cmp, Object o)
	{
		throw new UnsupportedOperationException("Manually adding a component to a SlidingPanel is not allowed.");
	}

	/**
	 * Not implemented. Please use {@link #setMainComponent(Component)} or
	 * {@link #setBorderComponent(Component, Position)} instead.
	 */
	@Override
	public Component add(String s, Component cmp)
	{
		throw new UnsupportedOperationException("Manually adding a component to a SlidingPanel is not allowed.");
	}

	/**
	 * Not implemented. Please use {@link #setMainComponent(Component)} or
	 * {@link #setBorderComponent(Component, Position)} instead.
	 */
	@Override
	public void add(Component cmp, Object o, int i)
	{
		throw new UnsupportedOperationException("Manually adding a component to a SlidingPanel is not allowed.");
	}

	// ---------------------------------------------------------------------------
	// Inner types
	// ---------------------------------------------------------------------------

	/**
	 * Defines a position where a border component can be installed.
	 */
	public enum Position
	{
		TOP
		{
			@Override
			public int shiftMainComponent(int pAmount)
			{
				return pAmount;
			}

			@Override
			public int computeYPosition(int pHeight, int pMaxY)
			{
				return pHeight;
			}
		},
		BOTTOM
		{
			@Override
			public int shiftMainComponent(int pAmount)
			{
				return -pAmount;
			}

			@Override
			public int computeYPosition(int pHeight, int pMaxY)
			{
				return pMaxY - pHeight;
			}
		};

		/**
		 * Computes by what amount the main component must be shifted to allow for the
		 * border component at this position to be displayed.
		 *
		 * @param pAmount The amount of pixels the main component should be shifted.
		 * @return The final shifting value applied to the main component.
		 */
		public abstract int shiftMainComponent(int pAmount);

		/**
		 * Computes the final y-position of a component at this position.
		 * @param pHeight Height of the component in pixels
		 * @param pMaxY Maximum y-value of the surrounding container.
		 * @return The computed y-value
		 */
		public abstract int computeYPosition(int pHeight, int pMaxY);
	}

	/**
	 * Contains all information about a border component. Used for internal storage.
	 *
	 * @author David
	 */
	private class BorderComponent
	{
		public BorderComponent(Component pComp)
		{
			this.mComponent = pComp;
			this.mShowSettings = new AnimationSettings();
			this.mHideSettings = new AnimationSettings();
		}

		public BorderComponent(Component pComp,
				Interpolator pShow, double pShowTime,
				Interpolator pHide, double pHideTime)
		{
			this.mComponent = pComp;
			this.mShowSettings = new AnimationSettings(pShow, pShowTime);
			this.mHideSettings = new AnimationSettings(pHide, pHideTime);
		}

		public Component			mComponent;
		public AnimationSettings	mShowSettings;
		public AnimationSettings	mHideSettings;

		@Override
		public String toString()
		{
			return "BorderComponent[Show=" + mShowSettings + ", Hide=" + mHideSettings + "]";
		}
	}

	private class AnimationSettings
	{
		public AnimationSettings()
		{
			mInterpolator	= mDefaultInterpolator;
			mDuration			= mDefaultAnimationDuration;
		}

		public AnimationSettings(Interpolator pInter, double pTime)
		{
			this.mInterpolator = pInter == DEFAULT_INTERPOLATOR ? mDefaultInterpolator : pInter;
			this.mDuration = pTime == DEFAULT_DURATION ? mDefaultAnimationDuration : pTime;
		}

		public Interpolator	mInterpolator;
		public double		mDuration;

		@Override
		public String toString()
		{
			return "[" + mInterpolator + ";" + mDuration + "s]";
		}
	}

	@Override
	public void mouseClicked(MouseEvent arg0)
	{
		this.hideBorderComponent();
	}

	@Override public void mouseEntered(MouseEvent e) { /* - Not needed - */  }
	@Override public void mouseExited(MouseEvent e) { /* - Not needed - */ }
	@Override public void mousePressed(MouseEvent e) { /* - Not needed - */ }
	@Override public void mouseReleased(MouseEvent e) { /* - Not needed - */ }
}
